上次有提到watch或watchEffect可以讓我們監聽饗應式數據,並且執行某些副作用(side effect),自己實務上常利用是監聽某項數值,例如監測使用者點擊的id去非同步呼叫api獲取資料,或者是長列表滾軸偵測使用者拉到哪個位置,要再去獲取下一筆資料等。
不過有些時候後端api可能回應時間比較長,使用者又很急著點選下一筆資料,多重資料呼叫請求下,過期的不需要的請求造成api回傳資料間的競態狀況(race condition),後來發現其實跟我們利用watch執行回調函式時,是不是習慣掛上後就不理它,而忘記清理過期副作用(clean up invalidate effect)…
onCleanUp的功能onWatcherCleanup
在 watch中,onCleanup函數是為第三個參數傳遞並可以傳給watch的回調函數(call back)作為清理副作用。
這個 onCleanup 函數允許你在監聽器或效果停止時執行清理操作。例如,你可以使用它來清除訂閱、移除事件監聽器或取消 API 請求,確保應用程序資源不會被浪費。它的作用是讓開發者在某些狀況下能夠妥善處理副作用。
watch(id, (newId, oldId, onCleanup) => {
  // ...
  onCleanup(() => {
    // cleanup logic
  })
})
watchEffect中onCleanup則是第一個參數,可以直接傳入回調函數(call back)作為清理副作用。。
watchEffect((onCleanup) => {
  // ...
  onCleanup(() => {
    // cleanup logic
  })
})
來看一下範例應該會比較清楚,下面有一個輸入框可以在裡面輸入數字,我們用watch 監聽輸入框得值,有變動的話會呼叫api獲取新的圖片,用Promise和setTimeout延遲一下回傳時間為3秒。
如果我們不利用onCleanup和預設一個expired標誌,作為api資料過期依據,在反覆輸入過程中應該會陸續看到多張圖片重疊更換的現象,也就是受到不必要的副作用干擾影響,發出去的request請求沒有適時處理清除掉,因為前面發出去的請求不一定會先回傳資料,會有所謂api競態狀況(race condition)問題。
有看到別篇文章很深入的解說源碼,其實onCleanup簡單來說有點類似註冊放在另一個閉包(closure)作用域。 當watch 真正的回調函式執行前,先去執行上一次註冊在onCleanup 內的函式,把上一個執行環境內的expired改成true,如果api回應時間還在等待,後續就不會進到image.value = res。
watch(message, async (newVal, oldVal, onInvalidate) => {
  let expired = false
  let data = ''
  onInvalidate(() => expired = true)
  
  try {
    let res = await new Promise((resolve) => {
      setTimeout(() => {
        console.log('call api')
        const id = Math.floor(Math.random() * 10) + 1
        resolve(`https://picsum.photos/id/${id}/300/300`)
      }, 3000)
    })
    
    if (!expired) {
      image.value = res
    }
  } catch (error) {
    console.error('Error:', error)
  }
})
onCleanup + Abort Signal 引入JavaScript fetch 達到取消請求,也是另一種作法。
import { ref, watch } from 'vue'
const message = ref('')
const image = ref('https://picsum.photos/200/300/')
watch(message, async (newVal, oldVal, onCleanUp) => {
  
  const controller = new AbortController()
  const signal = controller.signal
  onCleanUp(() => {
    console.log('onInvalidate triggered')
    controller.abort() // 取消請求
  })
  
  try {
    const id = Math.floor(Math.random() * 10) + 1
    // fetch 方法 接收abort signal會中斷請求
    const response = await fetch(`https://picsum.photos/id/${id}/300/300`, { signal })
    
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    // 如果请求未被取消 會更新圖片
    image.value = response.url
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Fetch request was aborted')
    } else {
      console.error('Fetch error:', error)
    }
  }
})
屬於還滿新的Vue版本更新功能,官方給的案例如下,不過看起來好像只是把清理函式,換個方式抽出監聽器另外註冊,官方給的範例看不太出差異~
import { watch, onWatcherCleanup } from 'vue'
watch(id, (newId) => {
  const controller = new AbortController()
  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // callback logic
  })
  onWatcherCleanup(() => {
    // abort stale request
    controller.abort()
  })
})
因為功能太新了,社群上還沒太多案例,不過有人分享文章如果onWatcherCleanUp做在共用邏輯組合式函式(composable)可能比較有使用上差異,composable觀念之後我們也會介紹~
import { onWatcherCleanup, shallowRef, onBeforeUnmount } from 'vue'
export function useFetchPhoto() {
  const abortController = shallowRef();
  /** 元件解除前,自動取消請求 */
  onBeforeUnmount(() => {
    abortController.value?.abort()
  });
  /** getData 實際發送請求的主體 */
  function getData(id) {
    return new Promise((resolve, reject) => {
      const controller = new AbortController()
      fetch(
        `https://picsum.photos/id/${id}/300/300`,
        { signal: controller.signal }
      )
        .then((res) => res.json())
        .then((value) => resolve(value))
        .catch((error) => {
          // 忽略終止錯誤
          if (error.name === 'AbortError') return;
          reject(error);
        })
      // 只要在 watcher 範圍內會自動取消前一次請求
       
      onWatcherCleanup(() => {
        controller.abort()
      }, true)
      abortController.value = controller;
    })
  }
  return {
    getData   
 }
  
}
使用onWatcherCleanUp和原本在每一個watch監聽器需要自己設定onCleanUp清除副作用不一樣,只要引入watch或watchEffct,會自動在監聽器作用域(scope)內,每一次回調觸發時先自動執行清除函式。
<script setup>
import { ref, watch } from 'vue'
import { useFetchPhoto } from './useFetchPhoto.js';
const message = ref('')
const image = ref('')
const { getData } = useFetchPhoto(); // 呼叫取得相片資料
watch(id, async (newId) => {
  image.value = await getData(newId); 
  // 這裡就不用再引入onCleanUp getData定義好的onWatcherCleanUp會自動在watch作用域內清除
})
async function getData() {
  id.value = crypto.randomUUID();
}
</script>
<template>
  <div id="app">
    <input v-model.lazy='message'/>
    <p>
      Learn more with the
      <a
        href="https://vuejs.org/"
        target="_blank"
        rel="noopener"
      >Vue Docs & Resources</a>.
    </p>
    <img :src="image" />
  </div>
</template>
監聽器watcher副作用的清除算是滿重要的小細節,因為它可以有效防止內存洩漏、競態條件造成的無效資料回傳造成的狀態更新等問題,特別是在處理異步操作(如 API 請求)或需要清理資源(如計時器、訂閱、DOM 操作)時。onWatcherCleanUp能自動在監聽器作用域內,在每一次監聽器回調執行前進行清除的動作,不過使用起來還是比隱晦不易察覺,一般清理副作用動作可以用原本onCleanUp定義就行囉。